返回 引气・Java 气海初拓

14聊天项目复盘:我的第一个聊天室,在演示现场崩溃了

博主
大约 8 分钟

14聊天项目复盘:我的第一个聊天室,在演示现场崩溃了

课程大作业演示日,30个同学刚连进我的“高性能聊天室”,服务器就卡成了幻灯片。我满脸通红地对着投影屏幕,手忙脚乱地查日志,而教授只是轻轻叹了口气:“孩子,网络编程不是开线程那么简单。”

image-20260201225954734

从“这很简单”到“我太天真了”

我的“完美”设计

“聊天室?不就是开个ServerSocket,来一个连接就new一个Thread嘛!”——三天前,我还在宿舍跟室友吹牛。

我当时的“架构图”简单得可爱:

text

客户端连接 → ServerSocket.accept() → 创建新线程 → 处理聊天 → 线程结束

代码看起来也很“优雅”:

java

// 我觉得这很简洁,不是吗?
while (true) {
    Socket client = serverSocket.accept();  // 这行会阻塞
    new Thread(() -> handleClient(client)).start();  // 为每个用户开线程!
}

灾难性的演示日

演示那天,我自信满满地打开了我的聊天室。前5个同学连接,一切正常。我开始得意地介绍:“看,我的系统支持多用户实时聊天!”

第10个同学连接时,消息开始有点延迟。

第20个同学连接时,服务器控制台开始疯狂刷错误日志。

第30个同学连接时——整个服务完全卡死。我试着在客户端输入消息,十秒钟后才显示出来。

教室里一片寂静。教授走到讲台前,拍了拍我的肩膀:“让我看看你的线程管理代码。”

当他看到我那无限创建线程的循环时,摇了摇头:“每个线程默认1MB栈内存,30个线程就是30MB。如果真有1000人同时在线呢?”

我这才意识到,我把“能运行”错当成了“能运行得好”

一、重新认识“简单”的Socket聊天室

1.1 第一个教训:线程不是免费的

image-20260201230030384

我重写了代码,加入了线程池:

java

// 版本2.0:至少不会因为线程太多而崩溃
ExecutorService threadPool = Executors.newFixedThreadPool(50); // 最多50个线程

while (true) {
    Socket client = serverSocket.accept();
    threadPool.submit(() -> handleClient(client));
}

但问题又来了——50个线程处理100个用户?当第51个用户连接时,他只能在队列里等着,直到有线程空闲。

1.2 第二个教训:广播消息是个性能炸弹

我最初的广播实现简单粗暴:

java

// 遍历所有客户端,逐个发送
for (Socket client : allClients) {
    OutputStream out = client.getOutputStream();
    out.write(message.getBytes());
}

当有100个在线用户时,每次有人发言,服务器都要执行100次网络写入。如果每秒有10条消息,就是1000次写入。我开始理解为什么演示时会卡了。

二、消息协议:从混乱到有序

2.1 第一次遇到“粘包”的困惑

有一天,用户抱怨说:“为什么我发的‘你好’和‘世界’在别人那里显示成了‘你好世界’?”

我调试了很久才发现,TCP为了效率,会把小数据包合并发送。我的简单读取逻辑:

image-20260201230049871

java

// 我以为这样就够了
String message = reader.readLine();

但实际上,我需要定义明确的消息边界:

java

// 最终方案:长度前缀协议
public void sendMessage(Socket socket, String content) throws IOException {
    byte[] data = content.getBytes("UTF-8");
    DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
    
    dos.writeInt(data.length);  // 先发长度:4字节
    dos.write(data);             // 再发数据
    dos.flush();
}

public String readMessage(Socket socket) throws IOException {
    DataInputStream dis = new DataInputStream(socket.getInputStream());
    
    int length = dis.readInt();  // 先读长度
    if (length <= 0 || length > 1024 * 1024) { // 防止恶意数据
        throw new IOException("无效消息长度");
    }
    
    byte[] data = new byte[length];
    dis.readFully(data);  // 精确读取指定长度的数据
    return new String(data, "UTF-8");
}

三、用户管理:从List到ConcurrentHashMap的进化

3.1 那些奇怪的并发bug

image-20260201230107875

有用户报告:“有时候我刚发的消息,系统却说‘用户不存在’。”

原因是我的ArrayList不是线程安全的。当一个用户退出时(一个线程在删除),另一个用户可能正在广播消息(遍历这个列表)。

java

// 错误版本:会有并发修改异常
List<ClientHandler> clients = new ArrayList<>();

// 正确版本:线程安全的映射
ConcurrentHashMap<String, ClientHandler> clients = new ConcurrentHashMap<>();

四、心跳机制:知道用户“还活着”

4.1 幽灵连接问题

服务器运行一天后,在线用户数显示有150人,但活跃聊天的只有80人。另外70个“幽灵连接”是客户端异常退出(比如直接关闭窗口、网络断开)留下的。

解决方案:心跳包。

java

// 在ClientHandler中添加心跳检测
class ClientHandler implements Runnable {
    private long lastHeartbeatTime = System.currentTimeMillis();
    
    public void updateHeartbeat() {
        lastHeartbeatTime = System.currentTimeMillis();
    }
    
    public boolean isTimeout() {
        return System.currentTimeMillis() - lastHeartbeatTime > 30000; // 30秒超时
    }
}

// 定时清理死连接的线程
class HeartbeatChecker implements Runnable {
    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(10000); // 每10秒检查一次
                
                for (ClientHandler client : ChatServer.getAllClients()) {
                    if (client.isTimeout()) {
                        System.out.println("清理超时连接: " + client.getUsername());
                        client.close();
                    }
                }
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}

五、最终版本:一个能实际使用的聊天室

image-20260201230124552

经过两个星期的重构,我的聊天室终于像个样子了:

java

public class StableChatServer {
    private ServerSocket serverSocket;
    private ExecutorService threadPool;
    private ConcurrentHashMap<String, ClientHandler> clients;
    private ScheduledExecutorService heartbeatExecutor;
    
    public void start(int port) throws IOException {
        serverSocket = new ServerSocket(port);
        threadPool = Executors.newFixedThreadPool(100);
        clients = new ConcurrentHashMap<>();
        heartbeatExecutor = Executors.newSingleThreadScheduledExecutor();
        
        // 启动心跳检测
        heartbeatExecutor.scheduleAtFixedRate(new HeartbeatChecker(), 10, 10, TimeUnit.SECONDS);
        
        System.out.println("聊天服务器启动在端口 " + port);
        
        while (!Thread.currentThread().isInterrupted()) {
            try {
                Socket clientSocket = serverSocket.accept();
                
                // 连接数控制
                if (clients.size() >= 1000) {
                    rejectConnection(clientSocket, "服务器已达最大连接数");
                    continue;
                }
                
                ClientHandler handler = new ClientHandler(clientSocket, this);
                threadPool.submit(handler);
                
            } catch (IOException e) {
                System.err.println("接受连接失败: " + e.getMessage());
            }
        }
    }
    
    // 线程安全的广播方法
    public void broadcast(String fromUser, String message) {
        Message chatMsg = new Message(MessageType.CHAT, fromUser, null, message);
        
        // 使用并行流提高性能(对于大量客户端)
        clients.values().parallelStream().forEach(client -> {
            if (client.isActive()) {
                client.sendMessage(chatMsg);
            }
        });
    }
}

六、那些只有踩过坑才懂的道理

6.1 我的学习清单

  1. 线程是重量级的:创建和销毁成本高,要用线程池管理
  2. TCP是流式协议:没有消息边界,需要自己定义协议
  3. 并发编程要小心:多个线程共享数据时,必须考虑线程安全
  4. 网络是不可靠的:客户端可能随时断开,要有心跳检测
  5. 资源是有限的:文件描述符、内存、线程数都有上限

6.2 如果重来一次,我会这样设计

  1. 先用简单的BIO实现:理解基本流程
  2. 马上重构为NIO:使用Selector管理多个连接
  3. 考虑使用Netty框架:避免重复造轮子
  4. 从第一天就添加监控:连接数、消息频率、内存使用
  5. 写单元测试:模拟多个客户端并发连接

结语:从崩溃中成长

演示失败的那个下午,我感到无比沮丧。但教授课后对我说的话,我现在都记得:

“每个优秀的程序员都写过糟糕的代码。重要的是,你能否从失败中学到什么。今天你学到了线程管理的教训,这比成功演示更有价值。”

现在,当我看到那些“简单”的技术方案时,总会多问自己几个问题:

  • 它能扩展到多少用户?
  • 失败情况下会怎么样?
  • 我该如何监控它的状态?

网络编程教会我的,不仅是技术,更是对复杂性的敬畏。 每一个简单的accept()背后,都可能藏着连接管理、资源限制、并发安全等一系列问题。

那个在演示现场崩溃的聊天室,最终成为了我最好的老师。它让我明白:代码不仅要写出来,更要经得起考验。

知识点测试

读完文章了?来测试一下你对知识点的掌握程度吧!

评论区

使用 GitHub 账号登录后即可发表评论,支持 Markdown 格式。

如果评论系统无法加载,请确保:

  • 您的网络可以访问 GitHub
  • giscus GitHub App 已安装到仓库
  • 仓库已启用 Discussions 功能